BridgeJS: Support generic functions at the Swift and JavaScript boundary#787
BridgeJS: Support generic functions at the Swift and JavaScript boundary#787krodak wants to merge 5 commits into
Conversation
kateinoigakukun
left a comment
There was a problem hiding this comment.
Thanks for tacking on this! I now feel like we still need some reorganization, which likely requires substantial design work to avoid complicating the codegen and ABI implementation.
| if let enumHelpers = enumCodegen.renderEnumHelpers(enumDef) { | ||
| decls.append(enumHelpers) | ||
| } | ||
| if hasGenerics, enumDef.enumType != .namespace { |
There was a problem hiding this comment.
| if hasGenerics, enumDef.enumType != .namespace { | |
| if enumDef.enumType != .namespace { |
I think "If a module contains any generic declaration" and "a type defined in a module can be used to substitute a generic type parameter" are unrelated. (e.g. Given a type X, which is defined in module A that does not have any generic declaration, module B should be able to pass type X to @JS func take<T>(_: T) defined in B.
| let structCodegen = StructCodegen() | ||
| for structDef in skeleton.structs { | ||
| decls.append(contentsOf: structCodegen.renderStructHelpers(structDef)) | ||
| if hasGenerics { |
| for function in skeleton.functions { | ||
| decls.append(try renderSingleExportedFunction(function: function)) | ||
| } | ||
| if hasGenericExports { |
| if function.isGeneric { | ||
| if function.effects.isStatic, let staticContext = function.staticContext { | ||
| return try GenericExportCodegen().renderExportedFunction( | ||
| function, | ||
| selfKind: .staticMember(staticContextBaseName(staticContext)) | ||
| ) | ||
| } | ||
| return try GenericExportCodegen().renderExportedFunction(function) | ||
| } |
There was a problem hiding this comment.
Let's avoid duplicating the entire call emission flow just for generics. I think most of the generics -specific stuff should be handled inside ExportedThunkBuilder without having a fresh new liftParameter -> call -> lowerReturnValue build sequence.
| ownerTypeName: String, | ||
| instanceSelfType: BridgeType | ||
| ) throws -> DeclSyntax { | ||
| if method.isGeneric { |
There was a problem hiding this comment.
ditto on my code duplication comment above
| // Generate ConvertibleToJSValue extension | ||
| decls.append(contentsOf: renderConvertibleToJSValueExtension(klass: klass)) | ||
|
|
||
| if hasGenerics, klass.isFinal == true { |
There was a problem hiding this comment.
ditto on my generic-availability comment above.
| " }", | ||
| " return id;", | ||
| "}", | ||
| "function __bjs_lowerArrayGeneric(value, codec) {", |
There was a problem hiding this comment.
Hmm, I feel this part is a kind of yellow sign of this approach. In the ideal case, the stack ABI of generic containers should be described once, and optionally instantiated with concrete type parameters. Maybe we need to revisit formalizing how we describe each type's stack ABI convention (like defining with DSL) so that we don't need to make this kind of code clone.
| printer.write("extension \(typeName): BridgedSwiftGenericBridgeable {") | ||
| printer.indent { | ||
| printer.write( | ||
| "@_spi(BridgeJS) public static let bridgeJSTypeID: Int32 = _swift_js_resolve_type_id(\"\(abiName)\")" |
There was a problem hiding this comment.
I think it'd be better to avoid string-based identification since it's fragile for cross-module declaration name conflicts. I'm thinking something like this.
final class BridgeJSTypeHandle {
let type: any BridgedSwiftGenericBridgeable.Type
init(_ type: any BridgedSwiftGenericBridgeable.Type) { type = type }
var typeID: UInt32 { Unmanaged.passUnretained(self).toOpaque() }
}
extension Point {
static let _bridgeJSTypeHandle = BridgeJSTypeHandle(Point.self)
}
@_export("bjs_TestModule_type_handles")
func bjs_TestModule_type_handles() {
withUnsafeTemporaryAllocation(of: UnsafeMutableRawPointer.self, capacity: NUM_OF_TYPES) { buf in
buf[0] = Point._bridgeJSTypeHandle.typeID
// ...
// Build lookup table in JS side keyed by type ID.
bjs_TestModule_register_type_handles(buf.baseAddress!, NUM_OF_TYPES)
}
}const STACK_HANDLE_BY_TYPEID = {}
imports["TestModule"]["bjs_TestModule_register_type_handles"] = function(base, count) {
// JS side stack operation table ordered in the same way as bjs_TestModule_type_handles's buffer
const STACK_HANDLES = [
{ push(...) { ... }, pop() { ... } },
{ push(...) { ... }, pop() { ... } },
...
]
const typeIDs = new Uint32Array(instance.exports.memory.buffer, base);
for (let i = 0; i < count; i++) {
STACK_HANDLE_BY_TYPEID[typeIDs[i]] = STACK_HANDLES[i]
}
}
Overview
Adds support for generic functions and methods to BridgeJS in both directions, constrained to a new bridgeable bound
BridgedSwiftGenericBridgeable. Values cross the boundary using each type's existing stack ABI, and a runtime type-ID registry selects the correct per-type codec, so the per-function glue stays type-agnostic. The@JS/@JSFunctionmacros are unchanged.The bound is
_BridgedSwiftStackTyperefined withStackLiftResult == Self(so a generic thunk can pop aT, which also structurally excludesJSObject) plus a singlebridgeJSTypeID: Int32requirement. That ID is the runtime identity handshake: each type's token string is interned through one wasm import exactly once (a lazystatic let), after which both sides exchange plaini32s; name-based interning is what keeps IDs consistent across independently compiled modules.Tmay be a supported primitive (Bool, any fixed-width integer,Float,Double,String, orJSValue), or any@JSstruct,final @JS class, or@JS enumdefined in the same module. It may be used bare (T) or wrapped as[T],T?, or[String: T].JSObjectcannot be used asT; useJSValueinstead.What's included
1. Import direction. Generic
@JSFunctionthunks (top-level or@JSClassmembers) are generic and monomorphized by the Swift compiler at the call site. One type-agnostic wasm import carries a trailing type-ID per generic parameter, and JS dispatches through a shared codec table. Supports a generic used in one or many parameters, multiple distinct generic parameters, and return-only generics (make<T>() -> T).2. Export direction. Generics work on top-level functions, instance and static methods of
@JSclasses and structs, and static methods of@JSenums. The wasm entry point is a concrete@_expose/@_cdeclthunk taking a trailing type-ID per generic parameter; the generic value crosses on the stack, andselfis threaded through the existential open-chain for instance methods. The thunk looks the type-ID up in a codegen-emitted registry and reifiesTthrough an opened existential (nested opening chain for multiple parameters). Concrete parameters of any supported bridged type may be mixed in with correct stack ordering. JavaScript callers pass a generatedBridgeType<T>token, exported as aBridgeTypesmap. Because this relies on opened existentials, the exported thunk is emitted as afatalErrorstub under#if hasFeature(Embedded); the import side stays Embedded-compatible and is now exercised end-to-end byExamples/Embedded(generic@JSFunctionround-tripping scalars,String, and a@JSstruct).3. Codecs and wrapped generics. Each codec (
{ lower, lift }) is generated from the canonical per-type stack fragments (stackLowerFragment/stackLiftFragment), the same path the non-generic glue uses, so there is no duplicated lowering to drift.[T],T?, and[String: T]bridge through the existingArray/Optional/Dictionaryconditional conformances on the Swift side, exactly like concrete types; in particular, numeric arrays keep the bulk typed-array fast path (the JS composition helper honors the existing-1bulk discriminator). This builds on the optional stack encoding unification (previous PR in this series), which made the optional stack form uniform across all types.4. Diagnostics. Build-time, source-located diagnostics for unsupported forms: missing or incorrect constraint,
whereclauses,asyncorthrowsgenerics, generics in@JSClassor static members, unsupported wrappings ([[T]],[T?],[Int: T]), and an export return type that is neither a declared generic (optionally wrapped) norVoid. The linker also fails the build when two linked modules define same-named@JStypes while generics are in use, since generic tokens are unqualified type names.5. Documentation. DocC articles (exporting/importing functions, supported types, unsupported features, internals rationale) and the BridgeJS README bridged-type table.
Testing
Tvst), and generic methods on each owning construct (class, struct, enum, namespace enum,@JSClass).Tin both directions, including the[T]/T?/[String: T]wrappers with empty collections andnil, bulk numeric arrays, heap-object reference semantics, consecutive calls with different types, and generic instance/static methods on classes, structs, and enums.Open questions
BridgeTypes, resolver, type registry) is only emitted for modules that actually declare generics. I considered dropping the gating to streamline the codegen, but the gates themselves are a handful of trivial conditionals, while removing them would regenerate every snapshot fixture and grow the generated output of every module that never uses generics. If you would rather have unconditional emission for simplicity, I am happy to remove the gating.Addresses #398